前兩天處理了離線同步,但還少考慮一個問題:如果同步失敗,要怎麼重新同步?
網路可能突然中斷、後端可能暫時無法回應,App 也可能在同步途中被系統關閉。
如果同步工作只存在記憶體中,一旦中斷,尚未完成的工作也會跟著消失。
因此我會加入兩個核心元件:
sync_queue:保存在 SQLite 中的待同步工作SyncWorker:負責執行 Queue 中的工作,並統一處理失敗、重試與恢復最直接的方式,是在需要同步時直接呼叫 API:
try {
await uploadWalkPoints();
} catch {
// 之後再試
}
但「之後再試」並沒有真正保存任何資訊。
如果 App 被關閉,這段程式也不會自己再次執行;如果不同畫面各自處理重試,也可能同時送出相同請求。
因此同步不應該依附在某個畫面上。
當本地資料發生需要同步的變化時,只需要在 SQLite 建立對應的工作:
await syncQueueRepository.enqueue({
jobType: 'upload_walk_points',
entityId: walkId,
});
至於什麼時候呼叫 API、失敗後要不要重試,以及下一次何時執行,全部交給 SyncWorker。
整體流程變成:
畫面 / App Lifecycle
↓
SQLite
↓
sync_queue
↓
SyncWorker
↓
後端
畫面只需要負責操作本地資料,不需要知道同步工作的細節。
GPS 本身仍然保存在 walk_points,sync_queue 不會複製一份 GPS,而是記錄「接下來要執行什麼同步工作」。
例如建立一場離線散步時,除了將散步資料寫入 walks,也會建立對應的 create_walk 工作。之後暫停、恢復、GPS 累積或結束散步時,再根據需要建立對應的同步工作。
因此一場散步可能會有:
create_walk
→ upload_walk_points
→ pause_walk
→ resume_walk
→ upload_walk_points
→ complete_walk
sync_queue 主要保存以下資訊:
| 欄位 | 用途 |
|---|---|
job_type |
要執行的同步工作 |
entity_id |
工作所屬的散步 |
job_sequence |
同一場散步中的工作順序 |
status |
目前的同步狀態 |
retry_count |
已經重試的次數 |
next_retry_at |
下次可以重試的時間 |
last_error |
最近一次失敗原因 |
工作會經過幾種狀態:
pending:等待執行processing:正在執行retry_wait:暫時失敗,等待下一次重試completed:已完成blocked:無法靠一般重試解決,需要額外處理這些資訊都保存在 SQLite,因此即使 App 在同步過程中被關閉,下次啟動時仍然可以知道哪些工作尚未完成,並從中斷的位置繼續處理。
GPS 每幾秒就會新增一筆,如果每個點都建立 upload_walk_points,Queue 很快就會累積大量重複工作。
因此新增 GPS 時,只需要確認目前是否已經存在待處理的上傳工作:
await database.transaction(async tx => {
await walkPointRepository.insert(tx, point);
const existingJob =
await syncQueueRepository.findPendingPointUpload(
tx,
walkId,
);
if (!existingJob) {
await syncQueueRepository.enqueue(tx, {
jobType: 'upload_walk_points',
entityId: walkId,
});
}
});
這樣同一場散步在同一時間只需要一筆「有 GPS 待同步」的工作。
真正執行時,再從 walk_points 取出尚未被後端確認的資料,批次上傳。
如果同步完成後又新增 GPS,再建立下一筆工作即可。
因此 Queue 管理的是同步工作,不是每一筆 GPS。
不同同步工作之間存在依賴:
create_walk
↓
upload_walk_points
↓
complete_walk
例如 complete_walk 不能在必要的 GPS 與 Pause 資料尚未同步前執行。
因此每場散步的同步工作會有自己的 job_sequence:
0 create_walk
1 upload_walk_points
2 pause_walk
3 resume_walk
4 upload_walk_points
5 complete_walk
這裡的 job_sequence 是同步工作的順序,和昨天提到的 GPS sequence 不同。
實際產生的工作數量會依散步過程而變化,這裡只是示意。
SyncWorker 取出工作時,會先確認前面的工作是否都已完成:
AND NOT EXISTS (
SELECT 1
FROM sync_queue earlier
WHERE earlier.entity_id = q.entity_id
AND earlier.job_sequence < q.job_sequence
AND earlier.status != 'completed'
)
這樣就能避免 complete_walk 提前執行。
SyncWorker 會不斷從 Queue 取出目前可以執行的工作:
取出可執行工作
↓
呼叫對應 API
↓
成功 → completed
↓
繼續下一筆
失敗
↓
判斷是否可以重試
↓
可以 → retry_wait
↓
不可以 → blocked
例如:
while (true) {
const job = await claimNextReadyJob();
if (!job) return;
try {
await process(job);
await markJobCompleted(job.id);
} catch (error) {
if (!isRetryableSyncError(error)) {
await markJobBlocked(job.id, getErrorMessage(error));
return;
}
await markJobRetry(job);
return;
}
}
不同的 job_type 再交給對應的處理邏輯:
switch (job.type) {
case 'create_walk':
// 建立散步
break;
case 'upload_walk_points':
// 批次上傳 GPS
break;
case 'pause_walk':
// 同步暫停
break;
case 'resume_walk':
// 同步恢復
break;
case 'complete_walk':
// 完成散步並取得正式統計
break;
}
同步失敗後,需要先判斷是不是暫時性的錯誤。
像 Network Error、Timeout、429、500、502、503、504,都有機會在稍後恢復。
這些錯誤會進入 retry_wait,並使用 Exponential Backoff:
2 秒
→ 4 秒
→ 8 秒
→ 16 秒
→ …
到達 next_retry_at 後,再次喚醒 SyncWorker 執行工作;如果 App 在等待期間被關閉,則由下次 App 啟動、回到前景或網路恢復時重新檢查 Queue。
例如:
const BASE_RETRY_MS = 2_000;
const MAX_RETRY_MS = 15 * 60_000;
function calculateBackoffMs(retryCount: number) {
const exponential = Math.min(
MAX_RETRY_MS,
BASE_RETRY_MS * 2 ** Math.max(0, retryCount - 1),
);
return Math.round(exponential * 1.2);
}
重試次數與 next_retry_at 都會保存到 SQLite。
如果是 400、403、404、422 等通常無法靠重送解決的錯誤,則標記為 blocked,避免 App 無限重試。
401 則可以先處理登入狀態,重新取得授權後再嘗試同步。
這也是整個 Queue 最重要的地方。
假設:
App
↓
POST /walk-points/batch
↓
後端已經成功保存
↓
Response 尚未回來
↓
App 被系統關閉
這時 Queue 可能還停留在 processing。
因此 App 下一次啟動時,會先執行:
await repairSyncQueue();
await syncWorker.run();
repairSyncQueue() 會檢查本地資料與 Queue 狀態:
processing 工作
→ 恢復成 pending
仍有未同步 GPS
→ 補上 upload_walk_points
尚未建立的散步
→ 補上 create_walk
已結束但尚未完成同步
→ 補上 complete_walk
這裡 Queue 並不是唯一依據。
sync_queue 記錄「目前有哪些同步工作」,而 walks、walk_points 等資料則代表「實際還有哪些資料需要同步」。
因此即使 App 在建立 Queue 工作之前就被關閉,也能從 SQLite 中重新推導出缺少的同步工作。
而昨天已經建立的 Idempotency 則負責處理另一個問題:即使 Crash Recovery 讓同一個 Request 再送一次,也不會在後端產生重複資料。
Queue 負責記住「還要做什麼」,Idempotency 負責確保「做兩次也沒關係」。
同步可能被很多事件觸發:
因此 SyncWorker 本身也需要避免重複執行:
private running: Promise<void> | null = null;
run(): Promise<void> {
if (this.running) {
return this.running;
}
this.running = this.drain().finally(() => {
this.running = null;
});
return this.running;
}
如果同步已經執行中,後續觸發只會共用同一個 Promise。
這只能避免同一個 App Process 中的重複執行;App 閃退或重新啟動時,仍然要依靠 SQLite Queue 與 Backend Idempotency 恢復。
使用者按下結束散步時,不會立即刪除 SQLite 中的 GPS 與其他資料。
流程會是:
必要資料同步完成
↓
complete_walk
↓
後端重新計算正式統計
↓
保存 local_walk_history
↓
清除散步暫存資料
local_walk_history 保存後端確認過的正式結果,因此即使之後離線,也能查看歷史摘要。
最後的本地整理會放在同一個 SQLite Transaction:保存正式歷史 + 清除 GPS / Pause / Navigation + 清除已完成的同步工作。
如果 Transaction 失敗,所有本地操作都會一起 rollback。
因此不會發生「歷史紀錄還沒保存,GPS 卻已經被刪掉」的情況。
今天處理的是離線模式最後一個問題:資料保存下來之後,如果同步失敗或 App 中途被關閉,要怎麼繼續?
因此加入 sync_queue 保存待同步工作,再由 SyncWorker 統一負責執行、重試與恢復。
GPS 不會每筆建立同步工作,而是由 upload_walk_points 批次處理尚未同步的資料;不同同步工作則透過 job_sequence 控制依賴順序。
遇到暫時性錯誤時使用 Exponential Backoff,無法自動恢復的錯誤則標記為 blocked。
即使 App 在同步途中被關閉,下次啟動時也能透過 repairSyncQueue() 根據 SQLite 中的資料重新補齊同步工作,再配合 Day 21 的 Idempotency 安全重送。
直到後端完成正式統計,而且正式結果成功保存到本地後,才會清除這場散步的暫存資料。
至此,離線模式從資料保存、可靠同步,到同步失敗後的恢復,整個流程完整收尾啦!